// parse a yarn lock file
// basic format
//
// <request spec>[, <request spec> ...]:
//   <key> <value>
//   <subkey>:
//     <key> <value>
//
// Assume that any key or value might be quoted, though that's only done
// in practice if certain chars are in the string.  Quoting unnecessarily
// does not cause problems for yarn, so that's what we do when we write
// it back.
//
// The data format would support nested objects, but at this time, it
// appears that yarn does not use that for anything, so in the interest
// of a simpler parser algorithm, this implementation only supports a
// single layer of sub objects.
//
// This doesn't deterministically define the shape of the tree, and so
// cannot be used (on its own) for Arborist.loadVirtual.
// But it can give us resolved, integrity, and version, which is useful
// for Arborist.loadActual and for building the ideal tree.
//
// At the very least, when a yarn.lock file is present, we update it
// along the way, and save it back in Shrinkwrap.save()
//
// NIHing this rather than using @yarnpkg/lockfile because that module
// is an impenetrable 10kloc of webpack flow output, which is overkill
// for something relatively simple and tailored to Arborist's use case.

const npa = require('npm-package-arg')
const consistentResolve = require('./consistent-resolve.js')

const prefix =
`# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


`

const nullSymbol = Symbol('null')
class YarnLock {
  static parse (data) {
    return new YarnLock().parse(data)
  }

  static fromTree (tree) {
    return new YarnLock().fromTree(tree)
  }

  constructor () {
    this.entries = null
    this.endCurrent()
  }

  endCurrent () {
    this.current = null
    this.subkey = nullSymbol
  }

  parse (data) {
    const ENTRY_START = /^[^\s].*:$/
    const SUBKEY = /^  [^\s]+:$/
    const SUBVAL = /^    [^\s]+ .+$/
    const METADATA = /^  [^\s]+ .+$/
    this.entries = new Map()
    this.current = null
    const linere = /([^\n]*)\n/gm
    let match
    let lineNum = 0
    if (!/\n$/.test(data))
      data += '\n'
    while (match = linere.exec(data)) {
      const line = match[1]
      lineNum ++
      if (line.charAt(0) === '#')
        continue
      if (line === '') {
        this.endCurrent()
        continue
      }
      if (ENTRY_START.test(line)) {
        this.endCurrent()
        const specs = this.splitQuoted(line.slice(0, -1), /, */)
        this.current = new YarnLockEntry(specs)
        specs.forEach(spec => this.entries.set(spec, this.current))
        continue
      }
      if (SUBKEY.test(line)) {
        this.subkey = line.slice(2, -1)
        this.current[this.subkey] = {}
        continue
      }
      if (SUBVAL.test(line) && this.current && this.current[this.subkey]) {
        const subval = this.splitQuoted(line.trimLeft(), ' ')
        if (subval.length === 2) {
          this.current[this.subkey][subval[0]] = subval[1]
          continue
        }
      }
      // any other metadata
      if (METADATA.test(line) && this.current) {
        const metadata = this.splitQuoted(line.trimLeft(), ' ')
        if (metadata.length === 2) {
          // strip off the legacy shasum hashes
          if (metadata[0] === 'resolved')
            metadata[1] = metadata[1].replace(/#.*/, '')
          this.current[metadata[0]] = metadata[1]
          continue
        }
      }

      throw Object.assign(new Error('invalid or corrupted yarn.lock file'), {
        position: match.index,
        content: match[0],
        line: lineNum,
      })
    }
    this.endCurrent()
    return this
  }

  splitQuoted (str, delim) {
    // a,"b,c",d"e,f => ['a','"b','c"','d"e','f'] => ['a','b,c','d"e','f']
    const split = str.split(delim)
    const out = []
    let o = 0
    for (let i = 0; i < split.length; i++) {
      const chunk = split[i]
      if (/^".*"$/.test(chunk))
        out[o++] = chunk.trim().slice(1, -1)
      else if (/^"/.test(chunk)) {
        let collect = chunk.trimLeft().slice(1)
        while (++i < split.length) {
          const n = split[i]
          // something that is not a slash, followed by an even number
          // of slashes then a " then end => ending on an unescaped "
          if (/[^\\](\\\\)*"$/.test(n)) {
            collect += n.trimRight().slice(0, -1)
            break
          } else
            collect += n
        }
        out[o++] = collect
      } else
        out[o++] = chunk.trim()
    }
    return out
  }

  toString () {
    return prefix + [...this.entries.values()]
      .map(e => e.toString())
      .sort((a, b) => a.localeCompare(b)).join('\n\n') + '\n'
  }

  fromTree (tree) {
    this.entries = new Map()
    for (const node of tree.inventory.values()) {
      const specs = [...node.edgesIn]
        .map(e => `${node.name}@${e.spec}`)
        .sort((a, b) => a.localeCompare(b))
      this.current = new YarnLockEntry(specs)
      if (node.package.dependencies)
        this.current.dependencies = node.package.dependencies
      if (node.package.optionalDependencies)
        this.current.optionalDependencies = node.package.optionalDependencies
      if (node.package.version)
        this.current.version = node.package.version
      if (node.resolved)
        this.current.resolved =
          consistentResolve(node.resolved, node.path, node.root.path)
      if (node.integrity)
        this.current.integrity = node.integrity
      specs.forEach(spec => this.entries.set(spec, this.current))
    }
    return this
  }

  static get Entry () {
    return YarnLockEntry
  }
}

const _specs = Symbol('_specs')
class YarnLockEntry {
  constructor (specs) {
    this[_specs] = new Set(specs)
    this.resolved = null
    this.version = null
    this.integrity = null
    this.dependencies = null
    this.optionalDependencies = null
  }

  toString () {
    // sort objects to the bottom, then alphabetical
    return ([...this[_specs]].map(JSON.stringify).join(', ') + ':\n' +
      Object.getOwnPropertyNames(this)
      .filter(prop => this[prop] !== null)
      .sort(
        (a, b) =>
          /* istanbul ignore next - sort call order is unpredictable */
          (typeof this[a] === 'object') === (typeof this[b] === 'object')
            ? a.localeCompare(b)
            : typeof this[a] === 'object' ? 1 : -1)
      .map(prop =>
        typeof this[prop] !== 'object'
          ? `  ${JSON.stringify(prop)} ${JSON.stringify(this[prop])}`
        : Object.keys(this[prop]).length === 0 ? ''
        : (`  ${prop}:\n` +
          Object.keys(this[prop]).sort((a, b) => a.localeCompare(b)).map(k =>
            `    ${JSON.stringify(k)} ${JSON.stringify(this[prop][k])}`)
          .join('\n')))
      .join('\n')).trim()
  }
}

module.exports = YarnLock
